<?php
require_once 'xapian.php';
require_once FORAGE_APP_PATH . '/engine/xapian/XapianMatchIterator.php';
/**
 * This class is responsible for interfacing with the Xapian
 * information retrieval system
 *
 * @author Rob Young <rob@roryoung.co.uk>
 * @package forage.engine.xapian
 */
class ForageXapianEngine extends ForageEngine implements ForageEngineModifiable, ForageEngineSearchable
{
  const DATABASE_WRITABLE = 1;
  const DATABASE_READONLY = 2;
  
  /**
   * Path to the Xapian index
   *
   * @var string
   */
  private $path;

  /**
   * Configuration options
   *
   * @var array
   */
  private $config;

  /**
   * Indexer instance
   * 
   * @var XapianTermGenerator
   */
  private $indexer;

  /**
   * The Xapian database
   *
   * @var XapianDatabase
   */
  private $database;

  /**
   * The type of Xapian database being used, readable or writable.
   *
   * @var string
   */
  private $database_type;

  /**
   * Initialize this engine
   *
   * @param string $dsn
   * @param array $config
   */
  public function initialize($dsn, $config)
  {
    $this->path   = $dsn;
    $this->config = $config;

    // check and set synonyms
    $this->setSynonyms();
  }

  /**
   * Perform a search on the Xapian database.
   *
   * @param ForageQuery $query
   * @return ForageResponse
   */
  public function search($query)
  {
    $enquire = new XapianEnquire($this->getReadonlyDatabase());
    
    $qp = $this->getQueryParser();
    if ($stopper = $this->getStopper()) {
      $qp->set_stopper($stopper);
    }
    $qp->set_database($this->getReadonlyDatabase());

    $q  = $qp->parse_query($query->query_string, XapianQueryParser::FLAG_AUTO_SYNONYMS);
    $enquire->set_query($q);
    if ($query->sort_fields) {
      foreach ($query->sort_fields as $field => $direction) {
        $enquire->set_sort_by_value($field, $direction==SORT_ASC?true:false);
      }
    }
    
    $matches = $enquire->get_mset($query->start_index, $query->end_index);
    
    $iterator = new XapianMatchIterator($matches);
    $response = new ForageResponse();
    $response->setTotal($matches->get_matches_estimated());
    foreach ($iterator as $match) {
      $doc = unserialize($match->get_document()->get_data());
      $doc->setProperty('score', $match->get_percent());
      $response[] = $doc;
    }
    return $response;
  }

  /**
   * Add a document to the database
   *
   * @param ForageDocument $document
   */
  public function add(ForageDocument $document)
  {
    try {
      $xapianDocument = new XapianDocument();
      $xapianDocument->set_data($this->serializeDocument($document));
      $indexer = $this->getIndexer();
      if ($stopper = $this->getStopper()) {
        $indexer->set_stopper($stopper);
      }
      $indexer->set_document($xapianDocument);
      $uidterm = null;
      foreach ($document->fields as $field) {
        if ($field->getProperty('uid')) {
          $uidterm = 'XU' . $field->value;
        } elseif ($field->getProperty('indexed', true) && $field->value) {
          $indexer->index_text($field->value);
          $indexer->increase_termpos(100);
        }
      }
      if ($uidterm) {
        $xapianDocument->add_term($uidterm);
        $this->getWritableDatabase()->replace_document($uidterm, $xapianDocument);
      } else {
        $database = $this->getWritableDatabase();
        $database->add_document($xapianDocument);
      }
    } catch (Exception $e) {
      throw new ForageXapianException($e->getMessage());
    }
  }

  /**
   * Remove a document from the xapian index
   *
   * @param ForageDocument $document
   */
  public function remove(ForageDocument $document)
  {
    try {
      $uid = $document->uid;
      if ($uid === null) {
        throw new ForageException("Document must have a uid");
      }
      $this->getWritableDatabase()->delete_document('XU' . $uid);
    } catch (Exception $e) {
      throw new ForageXapianException($e->getMessage());
    }
  }

  /**
   * Flush changes to the database
   *
   */
  public function flush() {
    $this->getWritableDatabase()->flush();
  }

  /**
   * Optimize the index
   */
  public function optimize()
  {
  }

  /**
   * Serialize a ForageDocument into a string, only storing stored fields.
   *
   * @param ForageDocument $document
   * @return string
   */
  private function serializeDocument(ForageDocument $document)
  {
    $newDoc = new ForageDocument();
    foreach ($document->fields as $field) {
      if ($field->getProperty('stored', true)) {
        $newDoc->add($field);
      }
    }

    foreach ($document->properties as $name => $value) {
      $newDoc->setProperty($name, $value);
    }

    return serialize($newDoc);
  }

  /**
   * Build the stemmer according to the configuration.
   *
   * @return XapianStem
   */
  private function getStemmer()
  {
    if (isset($this->config['stemmer']) && $this->config['stemmer'] === false) {
      $stemmer = false;
    } else {
      if (empty($this->config['stemmer'])) {
        $stemmer = 'english';
      } else {
        $stemmer = $this->config['stemmer'];
      }
      $stemmer = new XapianStem($stemmer);
    }
    return $stemmer;
  }

  /**
   * Build the stopper according to the configuration.
   *
   * @return XapianSimpleStopper
   */
  protected function getStopper()
  {
    if (empty($this->config['stop_words'])) {
      $stopper = false;
    } else {
      $stopper = new XapianSimpleStopper();
      foreach ($this->config['stop_words'] as $word) {
        $stopper->add($word);
      }
    }
    return $stopper;
  }

  /**
   * Get an instance of a writable database.
   *
   * @return XapianWritableDatabase
   */
  protected function getWritableDatabase()
  {
    return $this->getDatabase(self::DATABASE_WRITABLE);
  }

  /**
   * Get an instance of a readonly database.
   *
   * @return XapianDatabase
   */
  public function getReadonlyDatabase()
  {
    return $this->getDatabase(self::DATABASE_READONLY);
  }

  /**
   * Get an instance of a xapian database.
   *
   * @return XapianDatabase
   */
  private function getDatabase($type)
  {
    if (!$this->database || $this->database_type!=$type) {
      switch ($type) {
      case self::DATABASE_WRITABLE: 
        $this->database = new XapianWritableDatabase($this->path, Xapian::DB_CREATE_OR_OPEN); 
        break;
      case self::DATABASE_READONLY:
        $this->database = new XapianDatabase($this->path);
        break;
      default:
        throw new InvalidArgumentException("Expecting valid database type, recieved [" . $type . "]");
      }
      $this->database_type = $type;
    }
    return $this->database;
  }

  /**
   * Get an instance of the term generator (indexer) to use, set up with filters.
   *
   * @return XapianTermGenerator
   */
  protected function getIndexer()
  {
    if (!$this->indexer) {
      $indexer = new XapianTermGenerator();
      if ($stemmer = $this->getStemmer()) {
        $indexer->set_stemmer($stemmer);
      }
      $this->indexer = $indexer;
    }
    return $this->indexer;
  }

  /**
   * Get an instance of the query parser to use, set up with filters.
   *
   * @return XapianQueryParser
   */
  protected function getQueryParser()
  {
    $qp = new XapianQueryParser();
    if ($stemmer = $this->getStemmer()) {
      $qp->set_stemmer($stemmer);
      if (empty($this->config['stemming_strategy'])) {
        $qp->set_stemming_strategy(XapianQueryParser::STEM_SOME);
      } else {
        $qp->set_stemming_strategy($this->config['stemming_strategy']);
      }
    }
    return $qp;
  }
  
  /**
   * Apply any synonyms which need to be applied and remove and which need
   * to be removed.
   *
   */
  protected function setSynonyms()
  {
    if (isset($this->config['synonyms'])) {
      $db = $this->getWritableDatabase();
      $synonyms = array();
      for ($key_it = $db->synonym_keys_begin(); !$key_it->equals($db->synonym_keys_end()); $key_it->next()) {
        $key = $key_it->get_term();
        $set = array();
        for ($term_it = $db->synonyms_begin($key); !$term_it->equals($db->synonyms_end($key)); $term_it->next()) {
          $set[] = $term_it->get_term();
        }
        $synonyms[$key] = $set;
      }

      $flush = false;
      if (!$this->config['synonyms']) {
        foreach ($synonyms as $key => $set) {
          $flush = true;
          $db->clear_synonyms($key);
        }
      } elseif (is_array($this->config['synonyms'])) {
        $remove = array_diff(array_keys($synonyms), array_keys($this->config['synonyms']));
        foreach ($remove as $key) {
          $flush = true;
          $db->clear_synonyms($key);
        }
        foreach ($this->config['synonyms'] as $key => $set) {
          $sset   = isset($synonyms[$key]) ? $synonyms[$key] : array();
          $add    = array_diff($set, $sset);
          foreach ($add as $term) {
            $flush = true;
            $db->add_synonym($key, $term);
          }
          $remove = array_diff($sset, $set);
          foreach ($remove as $term) {
            $flush = true;
            $db->remove_synonym($key, $term);
          }
        }
      }
      if ($flush) {
        $db->flush();
      }
    }
  }
}

class ForageXapianException extends ForageException {}
